Command Bus Pattern
Command Bus は、下記2つを疎結合にする
Command (何をしたいか)
Command Handler (どう実行するか)
Command を適切な Handler にルーティングする
Coomand
実行したい操作を表すデータの塊
1 Command : 1 CommandHandler
Immutable
ビジネスロジックは持たない
CommandHandler
Command を受け取り、ドメインの振る舞いを実行
ビジネスロジックは書かない
実処理は AggregateRoot や Domain Service に委譲
CommandBus
Command を受け取る
対応する CommandHandler を探して実行する
code:ts
commandBus.register(CreateUserCommand, new CreateUserHandler())
commandBus.dispatch(
new CreateUserCommand(name, email)
)
めちゃくちゃ雑なイメージとしては、reducerが近いmrsekut.icon
静的オブジェクトが飛んできて、何らかの処理と紐づける、という点が似ている
しかし、reducerは純粋関数であり、stateを更新し、新しいstateを返す、という点は全く異なる
code:hs
reducer :: Action -> State -> State
busによって、commandに対応付けられたCommand Handlerが内部で実行される
Command Handlerは副作用の塊
Effectで書いたサンプル
Commandを定義して
code:ts
import { Data, Effect, Layer, Match, pipe } from 'effect';
/**
* Command の定義
*/
type Command = Data.TaggedEnum<{
CreateUser: { readonly name: string; readonly email: string };
DeactivateUser: { readonly userId: string };
UpdateEmail: { readonly userId: string; readonly newEmail: string };
}>;
const Command = Data.taggedEnum<Command>();
各Commandに対して、CommandHandlerを定義する
この、Command Handlerがめちゃくちゃ副作用的な処理になる
この例では、handlerの中でrepositoryを呼んでいる
code:ts
/**
* Command Handler の定義
* - Handler は Command を受け取り、ドメインの振る舞いを実行する。
*
* - Handler 自体にビジネスロジックを書かない
* - 実処理は Domain(Repository等)に委譲する
* - Handler は「調整役」に徹する
*/
const handleCreateUser = (cmd: Command & { _tag: 'CreateUser' }) =>
Effect.gen(function* () {
// バリデーション(簡易的な例)
if (!cmd.email.includes('@')) {
return yield* new ValidationError({ message: 'Invalid email format' });
}
// Domain(Repository)を呼び出す
const repo = yield* UserRepo;
const userId = yield* repo.create(cmd.name, cmd.email);
yield* Effect.log(Created user: ${userId});
return userId;
});
const handleDeactivateUser = (cmd: Command & { _tag: 'DeactivateUser' }) =>
Effect.gen(function* () {
const repo = yield* UserRepo;
yield* repo.deactivate(cmd.userId);
yield* Effect.log(Deactivated user: ${cmd.userId});
});
const handleUpdateEmail = (cmd: Command & { _tag: 'UpdateEmail' }) =>
Effect.gen(function* () {
if (!cmd.newEmail.includes('@')) {
return yield* new ValidationError({ message: 'Invalid email format' });
}
const repo = yield* UserRepo;
yield* repo.updateEmail(cmd.userId, cmd.newEmail);
yield* Effect.log(Updated email for: ${cmd.userId});
});
Command Busがこれ
Commandと、Command Handlerを紐づけているだけ。
これもDIの雰囲気があり、DIでも実装可能だろうと思う
その場合、各CommandがTagになる
まあでもhandlerを別のなにかに差し替えることない気がするから嬉しくないかmrsekut.icon
code:ts
/**
* Command Bus の定義
* - Command を受け取る
* - 対応する Handler を見つける(ルーティング)
* - Handler を実行する
*/
class CommandBus extends Effect.Service<CommandBus>()('app/CommandBus', {
effect: Effect.gen(function* () {
const route = (command: Command) =>
Match.value(command).pipe(
Match.tag('CreateUser', handleCreateUser),
Match.tag('DeactivateUser', handleDeactivateUser),
Match.tag('UpdateEmail', handleUpdateEmail),
Match.exhaustive,
);
return {
dispatch: (command: Command) =>
pipe(
Effect.log([CommandBus] Dispatching: ${command._tag}), // 横断的関心事: ログ出力
Effect.zipRight(route(command)),
),
};
}),
}) {}
この辺は読み飛ばしてok
code:ts
/**
* Domain Error の定義
*/
class ValidationError extends Data.TaggedError('ValidationError')<{
readonly message: string;
}> {}
/**
* UserRepo の定義
* - 実際のアプリでは DB アクセスなどを行う
*/
class UserRepo extends Effect.Service<UserRepo>()('app/UserRepo', {
effect: Effect.gen(function* () {
const create = (name: string, email: string) =>
Effect.sync(() => {
const id = user_${crypto.randomUUID().slice(0, 8)};
console.log([UserRepo] Created user: ${id} (${name}, ${email}));
return id;
});
const deactivate = (userId: string) =>
Effect.sync(() => {
console.log([UserRepo] Deactivated user: ${userId});
});
const updateEmail = (userId: string, email: string) =>
Effect.sync(() => {
console.log([UserRepo] Updated email for ${userId}: ${email});
});
return { create, deactivate, updateEmail };
}),
}) {}
実行するところの雰囲気
handlerを直接呼ぶのではなく、busでcommandをdispatchしている
code:ts
/**
* - アプリケーション層では CommandBus を通じて Command を発行する。
* - 直接 Handler を呼ばない。
* - なぜ Bus を経由する?
* - 横断的関心事(ログ、トランザクション等)を一箇所で処理できる
* - Handler の実装を隠蔽できる
* - 将来的に非同期キューに変更しやすい
*/
const program = Effect.gen(function* () {
const bus = yield* CommandBus;
// ユーザー作成
const userId = yield* bus.dispatch(
Command.CreateUser({ name: 'Taro', email: 'taro@example.com' }),
);
// メールアドレス更新
yield* bus.dispatch(
Command.UpdateEmail({
userId: userId as string,
newEmail: 'taro.new@example.com',
}),
);
// ユーザー無効化
yield* bus.dispatch(Command.DeactivateUser({ userId: userId as string }));
return userId;
});
const AppLayer = Layer.merge(UserRepo.Default, CommandBus.Default);
Effect.runPromise(
pipe(
program,
Effect.provide(AppLayer),
Effect.tap(id => Effect.log([App] Completed. User ID: ${id})),
),
).catch(console.error);